winbrew_app\operations\doctor\scan/
msi.rs

1use std::io::ErrorKind;
2use std::path::Path;
3
4use crate::core::hash::hash_file;
5use crate::core::{HashError, verify_hash};
6use crate::models::domains::installed::InstalledPackage;
7use crate::models::domains::inventory::MsiFileRecord;
8use crate::models::domains::reporting::{DiagnosisResult, DiagnosisSeverity};
9
10use super::{ScanResult, sort_diagnoses, sort_recovery_findings};
11
12pub(crate) type MsiInventoryScan = ScanResult;
13
14/// Verify a single MSI file against the stored inventory snapshot.
15pub(super) fn diagnose_msi_file(
16    pkg: &InstalledPackage,
17    file: &MsiFileRecord,
18) -> Option<DiagnosisResult> {
19    let path = Path::new(&file.path);
20
21    let metadata = match std::fs::metadata(path) {
22        Ok(metadata) => metadata,
23        Err(err) => {
24            return Some(diagnose_msi_file_error(pkg, file, err));
25        }
26    };
27
28    if !metadata.is_file() {
29        return Some(DiagnosisResult {
30            error_code: "msi_file_not_a_file".to_string(),
31            description: format!("{}: MSI file path is not a file ({})", pkg.name, file.path),
32            severity: DiagnosisSeverity::Error,
33        });
34    }
35
36    let (Some(hash_algorithm), Some(expected_hash)) =
37        (file.hash_algorithm, file.hash_hex.as_deref())
38    else {
39        return None;
40    };
41
42    let actual_hash = match hash_file(path, hash_algorithm) {
43        Ok(actual_hash) => actual_hash,
44        Err(err) => {
45            return Some(DiagnosisResult {
46                error_code: "msi_file_unreadable".to_string(),
47                description: format!(
48                    "{}: MSI file is unreadable for hashing ({}) - {}",
49                    pkg.name, file.path, err
50                ),
51                severity: DiagnosisSeverity::Error,
52            });
53        }
54    };
55
56    match verify_hash(expected_hash, actual_hash) {
57        Ok(()) => None,
58        Err(HashError::ChecksumMismatch { expected, actual }) => Some(DiagnosisResult {
59            error_code: "msi_file_hash_mismatch".to_string(),
60            description: format!(
61                "{}: MSI file hash mismatch for {} (expected {}, got {})",
62                pkg.name, file.path, expected, actual
63            ),
64            severity: DiagnosisSeverity::Error,
65        }),
66        Err(err) => Some(DiagnosisResult {
67            error_code: "msi_file_hash_unavailable".to_string(),
68            description: format!(
69                "{}: MSI file hash could not be verified for {} - {}",
70                pkg.name, file.path, err
71            ),
72            severity: DiagnosisSeverity::Error,
73        }),
74    }
75}
76
77/// Translate a filesystem metadata failure into an MSI file diagnosis.
78fn diagnose_msi_file_error(
79    pkg: &InstalledPackage,
80    file: &MsiFileRecord,
81    err: std::io::Error,
82) -> DiagnosisResult {
83    let (error_code, description) = match err.kind() {
84        ErrorKind::NotFound => (
85            "missing_msi_file",
86            format!("{}: missing MSI file ({})", pkg.name, file.path),
87        ),
88        ErrorKind::PermissionDenied => (
89            "msi_file_permission_denied",
90            format!("{}: MSI file permission denied ({})", pkg.name, file.path),
91        ),
92        _ => (
93            "msi_file_unreadable",
94            format!(
95                "{}: MSI file is unreadable ({}) - {}",
96                pkg.name, file.path, err
97            ),
98        ),
99    };
100
101    DiagnosisResult {
102        error_code: error_code.to_string(),
103        description,
104        severity: DiagnosisSeverity::Error,
105    }
106}
107
108pub(crate) fn scan_msi_inventory(
109    conn: &crate::database::DbConnection,
110    packages: &[InstalledPackage],
111) -> MsiInventoryScan {
112    let mut scan: MsiInventoryScan = Default::default();
113
114    for pkg in packages.iter().filter(|pkg| {
115        matches!(
116            pkg.engine_kind,
117            crate::models::domains::install::EngineKind::Msi
118        )
119    }) {
120        let snapshot = match crate::database::get_snapshot(conn, &pkg.name) {
121            Ok(Some(snapshot)) => snapshot,
122            Ok(None) => {
123                scan.push(
124                    DiagnosisResult {
125                        error_code: "missing_msi_inventory_snapshot".to_string(),
126                        description: format!("{}: MSI inventory snapshot is missing", pkg.name),
127                        severity: DiagnosisSeverity::Error,
128                    },
129                    None,
130                );
131                continue;
132            }
133            Err(err) => {
134                scan.push(
135                    DiagnosisResult {
136                        error_code: "msi_inventory_unreadable".to_string(),
137                        description: format!("{}: MSI inventory is unreadable - {err}", pkg.name),
138                        severity: DiagnosisSeverity::Error,
139                    },
140                    None,
141                );
142                continue;
143            }
144        };
145
146        for file in &snapshot.files {
147            if let Some(diagnosis) = diagnose_msi_file(pkg, file) {
148                scan.push(diagnosis, Some(Path::new(&file.path)));
149            }
150        }
151    }
152
153    sort_diagnoses(&mut scan.diagnostics);
154    sort_recovery_findings(&mut scan.recovery_findings);
155
156    scan
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use crate::core::paths::{ResolvedPaths, resolved_paths};
163    use crate::database;
164    use crate::models::domains::install::EngineKind;
165    use crate::models::domains::install::InstallerType;
166    use crate::models::domains::installed::PackageStatus;
167    use crate::models::domains::inventory::{
168        MsiComponentRecord, MsiInventoryReceipt, MsiInventorySnapshot, MsiRegistryRecord,
169        MsiShortcutRecord,
170    };
171    use crate::models::domains::reporting::{RecoveryActionGroup, RecoveryIssueKind};
172    use crate::models::domains::shared::HashAlgorithm;
173    use std::fs;
174    use std::path::PathBuf;
175    use tempfile::{TempDir, tempdir};
176
177    fn sample_file_record(name: &str, path: &Path, hash_hex: &str) -> MsiFileRecord {
178        let normalized_path = path
179            .to_string_lossy()
180            .replace('\\', "/")
181            .to_ascii_lowercase();
182
183        MsiFileRecord {
184            package_name: name.to_string(),
185            path: path.to_string_lossy().into_owned(),
186            normalized_path,
187            hash_algorithm: Some(HashAlgorithm::Sha256),
188            hash_hex: Some(hash_hex.to_string()),
189            is_config_file: false,
190        }
191    }
192
193    fn sample_snapshot(
194        name: &str,
195        install_dir: &std::path::Path,
196        hash_hex: &str,
197    ) -> MsiInventorySnapshot {
198        let install_dir = install_dir
199            .to_string_lossy()
200            .replace('\\', "/")
201            .to_ascii_lowercase();
202        let file_path = format!("{install_dir}/bin/demo.exe");
203
204        MsiInventorySnapshot {
205            receipt: MsiInventoryReceipt {
206                package_name: name.to_string(),
207                product_code: "{11111111-1111-1111-1111-111111111111}".to_string(),
208                upgrade_code: Some("{22222222-2222-2222-2222-222222222222}".to_string()),
209                scope: winbrew_models::domains::install::InstallScope::Installed,
210            },
211            files: vec![sample_file_record(name, Path::new(&file_path), hash_hex)],
212            registry_entries: vec![MsiRegistryRecord {
213                package_name: name.to_string(),
214                hive: "HKLM".to_string(),
215                key_path: "Software\\Demo".to_string(),
216                normalized_key_path: "software\\demo".to_string(),
217                value_name: "InstallPath".to_string(),
218                value_data: Some(install_dir.clone()),
219                previous_value: None,
220            }],
221            shortcuts: vec![MsiShortcutRecord {
222                package_name: name.to_string(),
223                path: format!("{install_dir}/Desktop/Demo.lnk"),
224                normalized_path: format!("{install_dir}/desktop/demo.lnk"),
225                target_path: Some(format!("{install_dir}/bin/demo.exe")),
226                normalized_target_path: Some(format!("{install_dir}/bin/demo.exe")),
227            }],
228            components: vec![MsiComponentRecord {
229                package_name: name.to_string(),
230                component_id: "COMPONENT-DEMO".to_string(),
231                path: Some(format!("{install_dir}/bin/demo.exe")),
232                normalized_path: Some(format!("{install_dir}/bin/demo.exe")),
233            }],
234        }
235    }
236
237    struct TestEnvironment {
238        _root: TempDir,
239        paths: ResolvedPaths,
240    }
241
242    impl TestEnvironment {
243        fn new() -> Self {
244            let root = tempdir().expect("temp dir should be created");
245            let paths = Self::build_paths(root.path());
246
247            Self { _root: root, paths }
248        }
249
250        fn with_storage() -> Self {
251            let env = Self::new();
252            database::init(&env.paths).expect("database should initialize");
253            env
254        }
255
256        fn build_paths(root: &Path) -> ResolvedPaths {
257            let packages = root.join("packages").to_string_lossy().into_owned();
258            let data = root.join("data").to_string_lossy().into_owned();
259            let logs = root.join("logs").to_string_lossy().into_owned();
260            let cache = root.join("cache").to_string_lossy().into_owned();
261
262            resolved_paths(root, &packages, &data, &logs, &cache)
263        }
264
265        fn packages_root(&self) -> &Path {
266            &self.paths.packages
267        }
268
269        fn create_dir(&self, path: &Path) {
270            fs::create_dir_all(path).expect("directory should be created");
271        }
272
273        fn write_file(&self, path: &Path, content: &[u8]) {
274            if let Some(parent) = path.parent() {
275                fs::create_dir_all(parent).expect("parent directory should be created");
276            }
277
278            fs::write(path, content).expect("file should be written");
279        }
280
281        fn db_conn(&self) -> database::DbConnection {
282            database::get_conn().expect("database connection")
283        }
284
285        fn insert_package(&self, package: &InstalledPackage) -> database::DbConnection {
286            let conn = self.db_conn();
287            database::insert_package(&conn, package).expect("insert package");
288            conn
289        }
290
291        fn make_msi_package(&self, name: &str) -> (InstalledPackage, PathBuf) {
292            let install_dir = self.packages_root().join(name);
293            (sample_package(name, &install_dir), install_dir)
294        }
295    }
296
297    fn assert_normalized_recovery_target_path(
298        finding: &crate::models::domains::reporting::RecoveryFinding,
299        expected_path: &Path,
300    ) {
301        let expected_path = expected_path
302            .to_string_lossy()
303            .replace('\\', "/")
304            .to_ascii_lowercase();
305
306        assert_eq!(finding.target_path.as_deref(), Some(expected_path.as_str()));
307    }
308
309    fn sample_package(name: &str, install_dir: &std::path::Path) -> InstalledPackage {
310        InstalledPackage {
311            name: name.to_string(),
312            version: "1.0.0".to_string(),
313            kind: InstallerType::Msi,
314            deployment_kind: InstallerType::Msi.deployment_kind(),
315            engine_kind: EngineKind::Msi,
316            engine_metadata: None,
317            install_dir: install_dir.to_string_lossy().into_owned(),
318            dependencies: Vec::new(),
319            status: PackageStatus::Ok,
320            installed_at: "2026-04-05T00:00:00Z".to_string(),
321        }
322    }
323
324    #[test]
325    fn diagnose_msi_file_error_maps_not_found_to_missing_msi_file() {
326        let temp_dir = tempdir().expect("temp dir should be created");
327        let package = sample_package("Contoso.Msi", temp_dir.path());
328        let file_path = temp_dir.path().join("missing.exe");
329        let hash_hex = "00".repeat(32);
330        let file = sample_file_record("Contoso.Msi", &file_path, &hash_hex);
331
332        let diagnosis = diagnose_msi_file_error(
333            &package,
334            &file,
335            std::io::Error::from(std::io::ErrorKind::NotFound),
336        );
337
338        assert_eq!(diagnosis.error_code, "missing_msi_file");
339        assert_eq!(diagnosis.severity, DiagnosisSeverity::Error);
340        assert!(diagnosis.description.contains("Contoso.Msi"));
341    }
342
343    #[test]
344    fn diagnose_msi_file_error_maps_permission_denied() {
345        let temp_dir = tempdir().expect("temp dir should be created");
346        let package = sample_package("Contoso.Msi", temp_dir.path());
347        let file_path = temp_dir.path().join("missing.exe");
348        let hash_hex = "00".repeat(32);
349        let file = sample_file_record("Contoso.Msi", &file_path, &hash_hex);
350
351        let diagnosis = diagnose_msi_file_error(
352            &package,
353            &file,
354            std::io::Error::from(std::io::ErrorKind::PermissionDenied),
355        );
356
357        assert_eq!(diagnosis.error_code, "msi_file_permission_denied");
358        assert_eq!(diagnosis.severity, DiagnosisSeverity::Error);
359        assert!(diagnosis.description.contains("Contoso.Msi"));
360    }
361
362    #[test]
363    fn scan_msi_inventory_attaches_file_restore_targets() {
364        let temp_dir = tempdir().expect("temp dir should be created");
365        let package = sample_package("Contoso.Msi", temp_dir.path());
366        let file_path = temp_dir.path().join("missing.exe");
367        let hash_hex = "00".repeat(32);
368        let file = sample_file_record("Contoso.Msi", &file_path, &hash_hex);
369
370        let diagnosis = diagnose_msi_file_error(
371            &package,
372            &file,
373            std::io::Error::from(std::io::ErrorKind::NotFound),
374        );
375        let mut scan: MsiInventoryScan = Default::default();
376        scan.push(diagnosis, Some(Path::new(&file.path)));
377
378        assert_eq!(scan.recovery_findings.len(), 1);
379        assert_eq!(
380            scan.recovery_findings[0].action_group,
381            Some(RecoveryActionGroup::FileRestore)
382        );
383        assert_eq!(
384            scan.recovery_findings[0].issue_kind,
385            RecoveryIssueKind::DiskDrift
386        );
387        assert_eq!(
388            scan.recovery_findings[0].target_path.as_deref(),
389            Some(file.path.as_str())
390        );
391    }
392
393    #[test]
394    fn scan_msi_inventory_detects_hash_mismatch() {
395        let env = TestEnvironment::with_storage();
396
397        let (package, install_dir) = env.make_msi_package("Contoso.Msi");
398        let file_path = install_dir.join("bin").join("demo.exe");
399        env.create_dir(file_path.parent().expect("file parent"));
400        env.write_file(&file_path, b"abc");
401
402        let snapshot = sample_snapshot(
403            "Contoso.Msi",
404            &install_dir,
405            "0000000000000000000000000000000000000000000000000000000000000000",
406        );
407
408        let mut conn = env.insert_package(&package);
409        database::replace_snapshot(&mut conn, &snapshot).expect("replace snapshot");
410
411        let scan = scan_msi_inventory(&conn, &[package]);
412
413        assert_eq!(scan.diagnostics.len(), 1);
414        assert_eq!(scan.diagnostics[0].error_code, "msi_file_hash_mismatch");
415        assert_eq!(scan.diagnostics[0].severity, DiagnosisSeverity::Error);
416        assert!(scan.diagnostics[0].description.contains("Contoso.Msi"));
417
418        assert_eq!(scan.recovery_findings.len(), 1);
419        assert_eq!(
420            scan.recovery_findings[0].issue_kind,
421            RecoveryIssueKind::DiskDrift
422        );
423        assert_eq!(
424            scan.recovery_findings[0].action_group,
425            Some(RecoveryActionGroup::FileRestore)
426        );
427        assert_normalized_recovery_target_path(&scan.recovery_findings[0], &file_path);
428    }
429
430    #[test]
431    fn scan_msi_inventory_detects_missing_files() {
432        let env = TestEnvironment::with_storage();
433
434        let (package, install_dir) = env.make_msi_package("Contoso.Msi");
435        env.create_dir(&install_dir);
436
437        let snapshot = sample_snapshot(
438            "Contoso.Msi",
439            &install_dir,
440            "0000000000000000000000000000000000000000000000000000000000000000",
441        );
442
443        let mut conn = env.insert_package(&package);
444        database::replace_snapshot(&mut conn, &snapshot).expect("replace snapshot");
445
446        let scan = scan_msi_inventory(&conn, &[package]);
447
448        assert_eq!(scan.diagnostics.len(), 1);
449        assert_eq!(scan.diagnostics[0].error_code, "missing_msi_file");
450        assert_eq!(scan.diagnostics[0].severity, DiagnosisSeverity::Error);
451        assert!(scan.diagnostics[0].description.contains("Contoso.Msi"));
452
453        assert_eq!(scan.recovery_findings.len(), 1);
454        assert_eq!(
455            scan.recovery_findings[0].issue_kind,
456            RecoveryIssueKind::DiskDrift
457        );
458        assert_eq!(
459            scan.recovery_findings[0].action_group,
460            Some(RecoveryActionGroup::FileRestore)
461        );
462        assert_normalized_recovery_target_path(
463            &scan.recovery_findings[0],
464            &install_dir.join("bin").join("demo.exe"),
465        );
466    }
467}